#!/usr/bin/env bash
set -euo pipefail
shopt -s globstar

usage() {
    cat >&2 <<EOF
usage: tools/tsflower SUBCOMMAND [ARGS...]

This tool runs TsFlower to generate Flow type definitions for
some of our dependencies using TS type definitions from upstream.
It also manages a stack of our own patches to the generated output.

Subcommands include:

  run - Rerun TsFlower, and reapply patches
  check - Check definitions up to date; used by \`tools/test tsflower\`
  unpack - Turn patches into a Git branch for editing, and rerun TsFlower
  pack - Turn a Git branch back into an updated set of patches
  help - Print this help


Workflow for revising the type definitions or patch stack:

  1. Run \`tools/tsflower unpack -s\` to unpack patches into a
     Git branch.  (The \`-s\` skips rerunning TsFlower.)

  2. Make desired changes and commit them, or use \`git rebase -i\`.

  3. Run \`tools/tsflower pack\` to re-pack the revised branch
     into patches.  This returns to your original branch, with the
     type definitions and patches updated to reflect your changes.

  4. Commit your changes.


Workflow for updating dependencies, full version:

  1. Update \`package.json\` and run \`yarn\` as usual.  Commit
     the results (though the commit may not yet be mergeable.)

  2. Run \`tools/tsflower unpack\` to unpack patches into a Git
     branch, rerun TsFlower, and attempt to rebase them on the
     updated definitions.

  3. Resolve any rebase conflicts to complete the rebase.

  4. Check that Flow passes; make any needed fixes and commit them.

  5. Run \`tools/tsflower pack\` as above.

  6. Commit your changes, and squash them into the commit that
     updated \`package.json\` and \`yarn.lock\`.

  7. Run \`tools/test\` to confirm the tests pass.  In particular
     this will run \`tools/tsflower check\`.


Workflow for updating dependencies, fast path:

  1. As above.

  2-5. Run \`tools/tsflower run\` to rerun TsFlower and re-apply
     patches.  If the dependency update didn't affect anything
     the patches touch, this should succeed.

     If this doesn't work, or if there are Flow errors, use the
     full workflow above with \`unpack\` and \`pack\`.

  6-7. As above.

EOF
}

this_file=$(readlink -f "$0")
rootdir=${this_file%/*/*}

bindir=${rootdir}/node_modules/.bin

cd "${rootdir}"

patch_dir=${rootdir}/types/patches

# Options to give `patch` our preferred behavior.
patch_opts=(
    --quiet  # Don't spew names of patched files.  (Let Git handle that info.)
    --no-backup-if-mismatch  # Let Git handle the version control.
    --reject-file -  # Don't spew `.rej` files on error.
    --force  # Don't ask questions, like "Unreversed patch detected!  Ignore -R?"
    --fuzz 0  # Don't ignore any context.  (Match `git am`'s behavior.)
)

no_uncommitted_changes()
{
    if ! git diff-index --quiet --cached HEAD; then
        # Index differs from HEAD.
        return 1
    fi
    if ! git diff-files --quiet; then
        # Worktree differs from index.
        return 1
    fi
}

check_no_uncommitted_changes()
{
    if ! no_uncommitted_changes; then
        echo >&2 "There are uncommitted changes.  Doing nothing, to avoid losing your work."
        return 1
    fi
}

run_on_package()
{
    # TODO(tsflower): put all this into a tsflower command
    local package="$1"
    local src="${rootdir}"/node_modules/"${package}"
    local dest="${rootdir}"/types/"${package}"

    rm -f "${dest}"/**/*.js.flow

    "${bindir}"/tsflower tree "${src}" "${dest}"

    local index="${dest}"/index.js.flow
    if ! [ -e "${index}" ]; then
        local typeref
        typeref=$(jq .types -r "${src}"/package.json)
        local at="@"
        echo >"${index}" "\
/* ${at}flow
 * ${at}generated
 */
export * from './${typeref%.d.ts}.js.flow';"
    fi
}

format_dir()
{
    local dir="$1"
    "${bindir}"/prettier-eslint --write --log-level silent \
        --eslint-config-path "${rootdir}"/tools/formatting.eslintrc.yaml \
        -- "${dir}"/**/*.js.flow
}

run_only()
{
    local package
    # TODO get list of packages from data... better yet, make it
    #   one tsflower command, reading a TsFlower config file

    package=expo-mail-composer
    run_on_package "${package}"
    format_dir "${rootdir}"/types/"${package}"

    package=@react-native-clipboard/clipboard
    run_on_package "${package}"
    format_dir "${rootdir}"/types/"${package}"

    package=react-native-document-picker
    run_on_package "${package}"
    format_dir "${rootdir}"/types/"${package}"

    package=react-native-open-notification
    run_on_package "${package}"
    format_dir "${rootdir}"/types/"${package}"

    package=expo-application
    run_on_package "${package}"
    format_dir "${rootdir}"/types/"${package}"

    package=react-native-image-picker
    run_on_package "${package}"
    format_dir "${rootdir}"/types/"${package}"

    package=expo-modules-core
    run_on_package "${package}"
    format_dir "${rootdir}"/types/"${package}"

    package=expo-screen-orientation
    run_on_package "${package}"
    format_dir "${rootdir}"/types/"${package}"

    package=expo-web-browser
    run_on_package "${package}"
    # TODO(tsflower): skip node_modules when acting on package,
    #   so we don't have to delete it here
    rm -rf "${rootdir}"/types/"${package}"/node_modules/
    format_dir "${rootdir}"/types/"${package}"

    package=react-native-safe-area-context
    run_on_package "${package}"
    format_dir "${rootdir}"/types/"${package}"

    package=@react-native-camera-roll/camera-roll
    run_on_package "${package}"
    format_dir "${rootdir}"/types/"${package}"

    for package in @react-navigation/{routers,core,native,stack,bottom-tabs,material-top-tabs}; do
        run_on_package "${package}"
        # TODO(tsflower): skip node_modules when acting on package,
        #   so we don't have to delete it here
        rm -rf "${rootdir}"/types/"${package}"/node_modules/
    done
    format_dir "${rootdir}"/types/@react-navigation

    package=react-native-tab-view
    run_on_package "${package}"
    # TODO: Send r-n-tab-view a .npmignore patch to stop shipping this directory.
    #   Also perhaps give TsFlower a way to tell it things to skip.
    rm -rf "${rootdir}"/types/"${package}"/lib/typescript/example/
    format_dir "${rootdir}"/types/"${package}"

    # Remove all *.web.js.flow files, etc. We don't use RN on those platforms.
    # TODO(tsflower): When tsflower supports skipping files, simplify away.
    rm -f "${rootdir}"/types/**/*.web.js.flow
    rm -f "${rootdir}"/types/**/*.macos.js.flow
    rm -f "${rootdir}"/types/**/*.windows.js.flow
}

apply_patches()
{
    for p in "${patch_dir}"/*.patch; do
        if ! patch "${patch_opts[@]}" -p0 <"${p}"; then
            echo >&2 "apply-patches: Failed at patch: ${p#"${rootdir}/"}"
            return 1
        fi
    done
}

run()
{
    run_only
    echo >&2 "TsFlower run complete.  Applying patches..."
    apply_patches
    echo >&2 'Patches complete!'
}

unapply_patches()
{
    local patches=( "${patch_dir}"/*.patch )
    local i p
    for (( i = ${#patches[@]} - 1; i >= 0; i-- )); do
        p="${patches[i]}"
        if ! patch "${patch_opts[@]}" -p0 -R <"${p}"; then
            echo >&2 "unapply-patches: Failed at patch: ${p#"${rootdir}/"}"
            return 1
        fi
    done
}

write_patches()
{
    local commit_range="${1:-upstream..tsflower}"

    rm -f "${patch_dir}"/*.patch

    local format_patch_config=(
        # Print the paths verbatim, without added a/ and b/ prefixes.
        # This makes it a bit simpler to jump to the file when seeing
        # the patch in the terminal.  (Corresponds to passing `-p0` to
        # `patch` and `git am` when consuming the patches.)
        -c diff.noprefix=true

        # Merge the context of hunks that nearly touch.  (Can make
        # diffs a bit more readable; more importantly, override user
        # config with some consistent value for reproducibility.)
        -c diff.interHunkContext=6
    )
    git "${format_patch_config[@]}" \
        format-patch --quiet --zero-commit \
        --no-thread --no-numbered --keep-subject \
        -o "${patch_dir}" "${commit_range}"

    # `git format-patch` writes the Git version number at the end.
    # For reproducibility, normalize that to an arbitrary (realistic) value.
    perl -i -0pe '
        s< ^        # beginning of a line (with "m" flag below)
           2 \. .*  # a 2.x version number (in case a future 3.x
                    #   actually makes a difference here)
           \n\n \Z  # end of line, blank line, end of file
         ><2.32.0\n\n>xm
      ' "${patch_dir}"/*.patch
}

check_consistent()
{
    if ! no_uncommitted_changes; then
        echo >&2 "There are uncommitted changes.  Aborting, to avoid losing your work."
        return 1
    fi

    unapply_patches

    # TODO: also catch if one patch touches something and another reverts
    if ! git diff --exit-code HEAD -- ':!types/*.js.flow'; then
        echo >&2 "Error: patches should only apply to type definitions in types/."
        return 1
    fi
    echo >&2 "Patches are clean.  Running TsFlower..."

    git add -u

    run_only

    if ! git diff --exit-code; then
        echo >&2 "Error: fresh TsFlower run didn't match existing definitions"
        return 1
    fi
    echo >&2 "TsFlower run successful."

    git reset --hard --quiet
}

check_branch_unused()
{
    local branchname="$1"
    if ! git rev-parse --verify --quiet "${branchname}" >/dev/null; then
        # Branch doesn't exist.
        return 0
    fi
    if ! git rev-list -n1 HEAD.."${branchname}" | grep -q .; then
        # Branch is an ancestor of HEAD.
        return 0
    fi
    # Branch does exist, and contains commits not in HEAD.
    return 1
}

unpack()
{
    local branchname=
    local shortcut=
    while (( $# )); do
        case "$1" in
            -s | --shortcut)
                shortcut=1; shift;;
            -*)
                echo >&2 "tools/tsflower unpack: bad option: $1"
                return 1;;
            *)
                if [ -n "${branchname}" ]; then
                    echo >&2 "tools/tsflower unpack: too many arguments"
                    return 1
                fi
                branchname="$1"
                shift;;
        esac
    done
    branchname="${branchname:-tsflower}"

    local basebranch="${branchname}-base"

    check_no_uncommitted_changes

    if git ls-files --others --exclude-standard -- 'types/*.js.flow' \
            | grep -q .; then
        cat >&2 <<EOF
There are untracked type definitions in types/:

EOF
        git status >&2 --short --untracked-files=normal -- 'types/*.js.flow'
        cat >&2 <<EOF

Doing nothing, to avoid losing your work.
Either remove them:
            git clean -ix 'types/*.js.flow'
or ignore them, by adding to \`.gitignore\` or your \`.git/info/exclude\`.
EOF
        return 1
    fi

    if ! check_branch_unused "${branchname}" \
            || ! check_branch_unused "${basebranch}"; then
        cat >&2 <<EOF
Branch '${branchname}' or '${basebranch}' already exists and is not an ancestor of HEAD.
Doing nothing, to avoid losing your work.
To choose a different branch name:
            tools/tsflower unpack SOMENAME
To delete the branches:
            git branch -D ${branchname@Q} ; git branch -D ${basebranch@Q}
To reset the branches:
            git branch -f ${branchname@Q} ; git branch -f ${basebranch@Q}
EOF
        return 1
    fi

    local start
    start=$(git rev-parse --verify --quiet @)

    git checkout --quiet -B "${basebranch}"
    unapply_patches
    git commit -am 'tsflower: Revert patches'

    git checkout --quiet --detach
    git am --quiet --whitespace=nowarn -p0 --keep "${patch_dir}"/*.patch
    git checkout --quiet -B "${branchname}"
    git branch --set-upstream-to "${basebranch}"

    if [ -z "${shortcut}" ]; then
        git checkout --quiet "${basebranch}"
        echo "Running TsFlower to regenerate type definitions from upstream..."
        run_only
        git add -- 'types/*.js.flow'
        git commit --allow-empty -m 'tsflower: Regenerate from upstream'
    fi

    echo
    echo "Base branch '${basebranch}':"
    git log --oneline --reverse --boundary --decorate-refs=refs/ \
        "${start}".."${basebranch}"

    echo
    echo "Patch branch '${branchname}':"
    git log --oneline --reverse --boundary --decorate-refs=refs/ \
        "${basebranch}".."${branchname}"

    if [ -z "${shortcut}" ]; then
        echo
        echo "Attempting rebase of '${branchname}' onto '${basebranch}'..."
        git checkout --quiet "${branchname}"
        if ! git rebase "${basebranch}"; then
            cat >&2 <<EOF

tools/tsflower: Rebase stopped.
After you resolve the issue and complete the rebase,
you can re-pack the revised branch into patches with:
    $ tools/tsflower pack
EOF
            return 1
        fi
    fi

    cat >&2 <<EOF

Check that Flow passes, and make commits for any needed fixes.
Then re-pack the revised branch into patches with:
    $ tools/tsflower pack
EOF
}

pack()
{
    check_no_uncommitted_changes

    local ref
    ref=$(git symbolic-ref HEAD)
    if ! [[ "${ref}" =~ ^refs/heads/ ]]; then
        echo >&2 "You are at a detached HEAD; no current branch."
        return 1
    fi
    local branch=${ref#refs/heads/}

    local baseref
    baseref=$(git for-each-ref --format='%(upstream)' "${ref}")
    if [[ "${baseref}" != "${ref}"-base ]]; then
        echo >&2 "Current branch '${branch}' does not have '${branch}-base' as upstream."
        echo >&2 "Aborting.  Did you want \`tools/tsflower unpack\` first?"
        return 1
    fi
    local basebranch=${baseref#refs/heads/}

    local basebaseref
    basebaseref=$(git for-each-ref --format='%(upstream)' "${baseref}")
    if ! [[ "${basebaseref}" =~ ^refs/heads/ ]]; then
        echo >&2 "Base branch '${basebranch}' has non-branch '${basebaseref}' as upstream."
        echo >&2 "Aborting.  Did you want \`tools/tsflower unpack\` first?"
        return 1
    fi
    local basebasebranch=${basebaseref#refs/heads/}

    git checkout "${basebasebranch}"
    git restore -s "${ref}" -- 'types/*.js.flow'
    write_patches "${baseref}".."${ref}"

    # These globs are quoted because we don't want the shell expanding
    # them (to files that actually exist): we want Git to receive them
    # literally and interpret them as Git "pathspecs", so that they cover
    # deleted files as well as still- or newly-existing files.
    git add --no-ignore-removal -- \
        'types/patches/*.patch' 'types/*.js.flow'

    cat >&2 <<EOF

Updated types and patches to reflect branch '${branch}'.
\`git diff --shortstat HEAD\` says the differences are:
  'types/*.js.flow':       $(git diff --shortstat @ -- 'types/*.js.flow')
  'types/patches/*.patch': $(git diff --shortstat @ -- 'types/patches/*.patch')

Leaving it to you to commit, or amend existing commit.
EOF
}


case "${1:-}" in
    check) check_consistent;;
    run) run;;  # TODO also as default
    run-only) run_only;;
    apply-patches) apply_patches;;
    unapply-patches) unapply_patches;;
    write-patches) write_patches "${2:-}";;
    unpack) unpack "${@:2}";;
    pack) pack;;
    help | --help) usage;;
    '' | *) usage; exit 2;;
esac
